进程和线程
- 进程:资源分配的最小单位,指内存中已运行的应用程序。每个进程都有一个独立的内存空间,一个应用程序可以同时运行多个进程,进程也是程序的一次执行过程,是系统运行程序的基本单位。
- 线程:CPU 调度的最小单位,线程是进程中的一个执行单元,负责当前进程中程序的执行,一个进程至少有一个线程,有多个线程的进程应用程序被叫做多线程程序。
简而言之,进程(颗粒大)和线程(颗粒小)都是对一个时间段的描述,是 CPU 工作时间段的描述,不过其颗粒大小不同。
并发与并行
《深入理解计算机系统》一书中如此描述:
- 并发(
Concurrency):若进程 B 的开始时间是在进程 A 的开始时间与结束时间之间,我们就说 A 和 B 是并发的。 - 并行(
Parallel Execution):并发的真子集,指同一时间两个进程运行在不同的机器上或者同一个机器不同的核心上。
打个比方,这就像我们早上起来刷牙烧水(用来洗脸洗头发)。
若是串行执行,必须一件一件执行:刷牙后再去烧水,要等水烧开。
若是并发执行,你可以先去烧水,在烧水的过程中进行刷牙操作,刷完牙水基本就开了。
若是并行执行,烧水的动作和你刷牙的动作同时发生,你可以左手拿着牙刷刷牙,右手拿着壶装满水去烧。
也就是说,上面把人当作了一个机器,而人的左右手充当了不同的核心(进程)。
硬件相关
缓存一致性问题
我们知道,计算机在执行程序的时候,每条指令都是在 CPU 中执行的,而执行的时候,又免不了要和数据打交道。而计算机上面的数据,又是存放在主存(内存)当中的。
随着 CPU 技术的发展,其执行速度越来越快。但由于内存技术并没有太大的变化,从内存中读取(或写入)数据的时间与 CPU 处理数据的时间相比,内存操作太慢了!
这就导致了一个问题: CPU 每次操作内存都要耗费很多时间去等待。
为了解决这个问题,工程师在 CPU 和内存之间增加了高速缓存。
那么,程序的执行过程就变成了:
在程序运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存当中,CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
而随着 CPU 能力的不断提升,一级缓存慢慢地无法满足要求了,因此又衍生出了多级缓存。
按照数据读取顺序和与 CPU 结合的紧密程度,缓存可以分为:
- 一级缓存(L1)
- 二级缓存(L3)
- 三级缓存(L3),存在于部分高端 CPU
缓存具有如下特点:
- 用来保存一份数据拷贝。
- 每一级缓存中所储存的全部数据都是下一级缓存的一部分。
- L1 到 L3 缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。
- 速度快,内存小,并且昂贵。
那么,在有了多级缓存之后,程序的执行就变成了:
当 CPU 要读取一个数据时,首先从一级缓存中查找,若没有找到再从二级缓存中查找,若还是没有就从三级缓存或内存中查找。
单核 CPU 只含有一套 L1,L2,L3 缓存;而多核 CPU 的每个核心都含有一套 L1(甚至和 L2)缓存,而共享 L3(或者和 L2)缓存。
随着计算机能力不断提升,开始支持多线程。
那么问题就来了,下面分别对单线程、多线程在单核 CPU、多核 CPU 中的缓存运作方式进行分析:
单线程:CPU 核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题。
单核 CPU 多线程:进程中的多个线程会同时访问进程中的共享数据,CPU 将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突。
多核 CPU 多线程:每个核都至少有一个 L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的 cache 中保留一份共享内存的缓存数据。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的 cache 之间的数据就有可能不同。
可以看到,在 CPU 和主存之间增加缓存,在多核 CPU 多线程场景下就可能存在缓存一致性问题。
换而言之,在多核 CPU 中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。
因此,在多处理器下, 为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
指令重排序
《Java 并发编程的艺术》一书中将指令重排序分为了三种:
- 编译器优化重排序
- 指令集并行重排序
- 内存系统重排序
这里简单理解为:指令重排序可能会重新安排将代码中语句的执行顺序。
为什么要指令重排序呢?
这是因为:处理器为了提高运算速度,会作出违背代码原有顺序的优化。
并发编程三大特性
为了保证数据的安全并发编程中需要满足以下三个特性:
- 原子性:保证一个操作或多个操作要么全部执行要么全部不执行
- 可见性:多个线程访问同一共享数据的时候,如果某一个线程修改了此共享数据,那么其他线程能够立即看到此数据的改变。(强制将工作内存中的此项数据更新至主内存中,且主内存中会更新所有工作内存中的此项数据)
- 有序性:程序执行的顺序按照代码的先后顺序执行
有没有发现,缓存一致性问题其实就是可见性问题,而指令重排序会导致原子性和有序性问题。
Java 内存模型
了解 Java 内存模型之前,先认识下什么是内存模型。
什么是内存模型?
为了保证共享内存的正确性(原子性、可见性、有序性),出现了内存模型的概念。内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的线程安全。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
什么是 Java 内存模型?
Java 内存模型(Java Memory Model ,JMM)是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
JMM 作用于工作内存和主存之间数据同步过程,它规定了如何做数据同步及什么时候做数据同步:

其中的主内存和工作内存,可以简单的类比成计算机内存模型中的主存和缓存的概念。特别需要注意的是,主内存和工作内存与 JVM 内存结构中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。
《深入理解Java虚拟机》中认为,如果一定要勉强对应起来的话,从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分。工作内存则对应于虚拟机栈中的部分区域。
那么,JMM 是如何确保数据同步的呢?
JMM 通过定义 8 种同步操作及相关规则来确保数据同步。
JMM 的同步操作及规则
这 8 种同步操作见下图及解释:
- Lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态;
- Read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 Load 操作使用
- Load(载入):作用于工作内存的变量,把 Read 操作从主内存中得到的变量值放入到工作内存的变量副本中
- Use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
- Assign(赋值):作用于工作内存的变量,把一个从执行引擎接收到的值赋值给工作内存的变量
- Store(存储):作用于工作内存的变量,把工作内存中的一个变量值传送到主内存中,以便随后的 Write 操作
- Write(写入):作用于主内存的变量,把 Store 操作从工作内存中一个变量的值传递到主内存的变量中
- Unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
而 JMM 规定的同步规则包括:
- 若要把一个变量从主内存复制到工作内存,需要按顺序地执行 Read 和 Load 操作;若把变量从工作内存同步回主内存中,需要按顺序地执行 Store 和 Write 操作。这些操作必须按顺序执行,但不一定需要连续执行
- 不允许 Read 和 Load、Store 和 Write 操作之一单独出现
- 不允许一个线程丢弃它最近的 Assgn 操作,即变量在工作内存改变后必须同步到主内存中
- 不允许一个线程无故(未发生任何 Assgin 操作)把数据从工作内存同步回主内存中
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(Load 或 Assign )的变量。即对一个变量进行 Use 和 Store 操作之前,必须先执行 Load 和 Assign 操作
- Lock 和 Unlock 操作必须成对出现:一个变量在同一时刻只允许一条线程对其进行 Lock 操作,但 Lock 操作可以被同一线程重复执行多次,多次执行 Lock 操作后,只有执行相同次数的 Unlock 操作,变量才被解锁。
- 若对一个变量执行 Lock 操作,将清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 Load 或 Assign 操作初始化变量的值
- 若一个变量事先未被 Lock 操作锁定,则不允许对它执行 Unlock 操作,也不允许去 Unlock 一个被其他线程锁定的变量
- 对一个变量执行 Unlock 操作之前,必须先把此变量同步到主内存中(执行 Store 和 Write 操作)
参考
- Randal E.Bryant / David O’Hallaron 深入理解计算机系统 [M]. 机械工业出版社,2016
- 线程和进程的区别是什么? - zhonyong的回答 - 知乎
- 再有人问你 Java 内存模型是什么,就把这篇文章发给他